# Spotify MCP Server Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Build a Python MCP server exposing Spotify library and playlist management as 6 tools for Claude via stdio transport. **Architecture:** Two source files — `spotify_client.py` wraps spotipy (auth + all API calls, including pagination and data shaping), `server.py` handles MCP tool registration and routing. The `mcp` SDK handles stdio; `spotipy` handles OAuth PKCE flow and token caching. Token is cached relative to the script file so Claude Code always finds it regardless of working directory. **Tech Stack:** Python 3.11+, `mcp` (MCP Python SDK), `spotipy` (Spotify Web API + OAuth), `python-dotenv` --- ### Task 1: Initialize project **Files:** - Create: `pyproject.toml` - Create: `.env.example` - Create: `.gitignore` - [ ] **Step 1: Initialize git repository** ```bash cd /Users/kriss/Dev/scm/scm.vilanet.fr/kriss/mcp-spotify git init ``` Expected: `Initialized empty Git repository in .../mcp-spotify/.git/` - [ ] **Step 2: Create pyproject.toml** ```toml [project] name = "mcp-spotify" version = "0.1.0" requires-python = ">=3.11" dependencies = [ "mcp>=1.0.0", "spotipy>=2.24.0", "python-dotenv>=1.0.0", ] [build-system] requires = ["setuptools>=68"] build-backend = "setuptools.build_meta" [tool.setuptools] py-modules = ["server", "spotify_client"] ``` - [ ] **Step 3: Create .env.example** ``` SPOTIPY_CLIENT_ID=your_client_id_here SPOTIPY_CLIENT_SECRET=your_client_secret_here SPOTIPY_REDIRECT_URI=http://localhost:8888/callback ``` - [ ] **Step 4: Create .gitignore** ``` .env .venv/ __pycache__/ *.pyc .cache *.egg-info/ dist/ ``` - [ ] **Step 5: Create virtual environment and install dependencies** ```bash python3 -m venv .venv source .venv/bin/activate pip install -e . ``` Expected: Dependencies install without errors (`mcp`, `spotipy`, `python-dotenv` all present). Verify with: `pip show mcp spotipy python-dotenv` — all three should display version info. - [ ] **Step 6: Commit** ```bash git add pyproject.toml .env.example .gitignore git commit -m "chore: initialize project scaffold" ``` --- ### Task 2: Implement Spotify client **Files:** - Create: `spotify_client.py` - [ ] **Step 1: Create spotify_client.py** ```python import os import pathlib from dotenv import load_dotenv import spotipy from spotipy.oauth2 import SpotifyOAuth from spotipy.cache_handler import CacheFileHandler load_dotenv() _SCOPES = " ".join([ "playlist-read-private", "playlist-read-collaborative", "playlist-modify-public", "playlist-modify-private", "user-library-read", ]) # Cache relative to this file so Claude Code always finds it regardless of cwd _CACHE_PATH = pathlib.Path(__file__).parent / ".cache" _client: spotipy.Spotify | None = None def get_client() -> spotipy.Spotify: global _client if _client is None: client_id = os.environ.get("SPOTIPY_CLIENT_ID") client_secret = os.environ.get("SPOTIPY_CLIENT_SECRET") redirect_uri = os.environ.get("SPOTIPY_REDIRECT_URI", "http://localhost:8888/callback") if not client_id or not client_secret: raise RuntimeError( "Missing SPOTIPY_CLIENT_ID or SPOTIPY_CLIENT_SECRET. " "Copy .env.example to .env and fill in your Spotify app credentials." ) _client = spotipy.Spotify( auth_manager=SpotifyOAuth( client_id=client_id, client_secret=client_secret, redirect_uri=redirect_uri, scope=_SCOPES, cache_handler=CacheFileHandler(cache_path=str(_CACHE_PATH)), ) ) return _client def _format_duration(ms: int) -> str: seconds = ms // 1000 minutes, seconds = divmod(seconds, 60) return f"{minutes}:{seconds:02d}" def list_playlists() -> list[dict]: sp = get_client() results = [] response = sp.current_user_playlists(limit=50) while response: for item in response["items"]: results.append({ "id": item["id"], "name": item["name"], "description": item.get("description", ""), "tracks_total": item["tracks"]["total"], "public": item["public"], "uri": item["uri"], }) response = sp.next(response) if response["next"] else None return results def get_playlist_tracks(playlist_id: str) -> list[dict]: sp = get_client() results = [] response = sp.playlist_items(playlist_id, additional_types=["track"]) while response: for item in response["items"]: track = item.get("track") if not track or track.get("type") != "track": continue results.append({ "name": track["name"], "artists": [a["name"] for a in track["artists"]], "album": track["album"]["name"], "duration_ms": track["duration_ms"], "duration": _format_duration(track["duration_ms"]), "popularity": track["popularity"], "uri": track["uri"], "added_at": item["added_at"], }) response = sp.next(response) if response["next"] else None return results def list_saved_tracks(limit: int = 50) -> list[dict]: sp = get_client() results = [] response = sp.current_user_saved_tracks(limit=min(limit, 50)) while response and len(results) < limit: for item in response["items"]: if len(results) >= limit: break track = item["track"] results.append({ "name": track["name"], "artists": [a["name"] for a in track["artists"]], "album": track["album"]["name"], "duration_ms": track["duration_ms"], "duration": _format_duration(track["duration_ms"]), "popularity": track["popularity"], "uri": track["uri"], "added_at": item["added_at"], }) if response["next"] and len(results) < limit: response = sp.next(response) else: break return results def search_tracks(query: str, limit: int = 10) -> list[dict]: sp = get_client() response = sp.search(q=query, type="track", limit=min(limit, 50)) results = [] for track in response["tracks"]["items"]: results.append({ "name": track["name"], "artists": [a["name"] for a in track["artists"]], "album": track["album"]["name"], "duration_ms": track["duration_ms"], "duration": _format_duration(track["duration_ms"]), "popularity": track["popularity"], "uri": track["uri"], }) return results def create_playlist(name: str, description: str = "", public: bool = False) -> dict: sp = get_client() user_id = sp.current_user()["id"] playlist = sp.user_playlist_create( user=user_id, name=name, public=public, description=description, ) return { "id": playlist["id"], "name": playlist["name"], "uri": playlist["uri"], "public": playlist["public"], } def add_tracks_to_playlist(playlist_id: str, track_uris: list[str]) -> dict: sp = get_client() for i in range(0, len(track_uris), 100): sp.playlist_add_items(playlist_id, track_uris[i : i + 100]) return {"added": len(track_uris)} ``` - [ ] **Step 2: Verify the module imports without errors** ```bash source .venv/bin/activate python -c "import spotify_client; print('OK')" ``` Expected: `OK` — no import errors (`.env` is not required at import time, only when `get_client()` is called). - [ ] **Step 3: Commit** ```bash git add spotify_client.py git commit -m "feat: implement Spotify client module" ``` --- ### Task 3: Implement MCP server **Files:** - Create: `server.py` - [ ] **Step 1: Create server.py** ```python import asyncio import json import sys from mcp.server import Server from mcp.server.stdio import stdio_server from mcp.types import Tool, TextContent import spotify_client server = Server("spotify-mcp") @server.list_tools() async def handle_list_tools() -> list[Tool]: return [ Tool( name="list_playlists", description="List the authenticated user's Spotify playlists.", inputSchema={"type": "object", "properties": {}, "required": []}, ), Tool( name="get_playlist_tracks", description=( "Get all tracks in a Spotify playlist with full metadata: " "name, artists, album, duration_ms, duration (mm:ss), popularity, URI, added_at." ), inputSchema={ "type": "object", "properties": { "playlist_id": { "type": "string", "description": "Spotify playlist ID (from list_playlists)", }, }, "required": ["playlist_id"], }, ), Tool( name="list_saved_tracks", description="Get the user's liked/saved tracks from Spotify.", inputSchema={ "type": "object", "properties": { "limit": { "type": "integer", "description": "Maximum number of tracks to return (default 50)", }, }, "required": [], }, ), Tool( name="search_tracks", description=( "Search Spotify for tracks. " "Returns track URIs that can be passed to add_tracks_to_playlist." ), inputSchema={ "type": "object", "properties": { "query": {"type": "string", "description": "Search query"}, "limit": { "type": "integer", "description": "Maximum results to return (default 10, max 50)", }, }, "required": ["query"], }, ), Tool( name="create_playlist", description="Create a new Spotify playlist for the authenticated user.", inputSchema={ "type": "object", "properties": { "name": {"type": "string", "description": "Playlist name"}, "description": { "type": "string", "description": "Playlist description (optional)", }, "public": { "type": "boolean", "description": "Whether the playlist is public (default false)", }, }, "required": ["name"], }, ), Tool( name="add_tracks_to_playlist", description="Add tracks to a Spotify playlist by their URIs (e.g. spotify:track:...).", inputSchema={ "type": "object", "properties": { "playlist_id": { "type": "string", "description": "Spotify playlist ID", }, "track_uris": { "type": "array", "items": {"type": "string"}, "description": "List of Spotify track URIs", }, }, "required": ["playlist_id", "track_uris"], }, ), ] @server.call_tool() async def handle_call_tool(name: str, arguments: dict | None) -> list[TextContent]: arguments = arguments or {} try: if name == "list_playlists": result = await asyncio.to_thread(spotify_client.list_playlists) elif name == "get_playlist_tracks": result = await asyncio.to_thread( spotify_client.get_playlist_tracks, arguments["playlist_id"] ) elif name == "list_saved_tracks": result = await asyncio.to_thread( spotify_client.list_saved_tracks, arguments.get("limit", 50) ) elif name == "search_tracks": result = await asyncio.to_thread( spotify_client.search_tracks, arguments["query"], arguments.get("limit", 10), ) elif name == "create_playlist": result = await asyncio.to_thread( spotify_client.create_playlist, arguments["name"], arguments.get("description", ""), arguments.get("public", False), ) elif name == "add_tracks_to_playlist": result = await asyncio.to_thread( spotify_client.add_tracks_to_playlist, arguments["playlist_id"], arguments["track_uris"], ) else: raise ValueError(f"Unknown tool: {name}") except Exception as e: return [TextContent(type="text", text=f"Error: {e}")] return [TextContent(type="text", text=json.dumps(result, ensure_ascii=False, indent=2))] async def _run_server() -> None: async with stdio_server() as (read_stream, write_stream): await server.run( read_stream, write_stream, server.create_initialization_options(), ) if __name__ == "__main__": if "--auth" in sys.argv: print("Authenticating with Spotify (a browser window will open)...") spotify_client.get_client() print("Authentication successful! Token cached.") sys.exit(0) try: spotify_client.get_client() except RuntimeError as e: print(f"Startup error: {e}", file=sys.stderr) sys.exit(1) asyncio.run(_run_server()) ``` - [ ] **Step 2: Verify server.py imports and syntax are clean** ```bash source .venv/bin/activate python -c "import server; print('OK')" ``` Expected: `OK` - [ ] **Step 3: Commit** ```bash git add server.py git commit -m "feat: implement MCP server with 6 Spotify tools" ``` --- ### Task 4: First auth run, README, and Claude Code registration **Files:** - Create: `README.md` - [ ] **Step 1: Copy .env.example to .env and fill in credentials** Go to https://developer.spotify.com/dashboard, create an app, add `http://localhost:8888/callback` as a Redirect URI, then: ```bash cp .env.example .env # Open .env in an editor and fill in SPOTIPY_CLIENT_ID and SPOTIPY_CLIENT_SECRET ``` - [ ] **Step 2: Run the one-time auth flow** ```bash source .venv/bin/activate python server.py --auth ``` Expected: Browser opens Spotify login → after authorizing, terminal prints: ``` Authentication successful! Token cached. ``` The token is saved to `.cache` in the project directory. All subsequent runs (including when launched by Claude Code) load it silently without opening a browser. - [ ] **Step 3: Create README.md** ````markdown # mcp-spotify A Python MCP server that exposes Spotify library and playlist management as tools for Claude. ## Prerequisites - Python 3.11+ - A Spotify Developer app — [create one here](https://developer.spotify.com/dashboard) - In the app settings, add `http://localhost:8888/callback` as a Redirect URI ## Setup **1. Install dependencies** ```bash python3 -m venv .venv source .venv/bin/activate # Windows: .venv\Scripts\activate pip install -e . ``` **2. Configure credentials** ```bash cp .env.example .env # Edit .env: fill in SPOTIPY_CLIENT_ID and SPOTIPY_CLIENT_SECRET ``` **3. Authenticate with Spotify (once)** ```bash source .venv/bin/activate python server.py --auth ``` A browser window opens. Log in and authorize the app. The token is cached to `.cache` in the project directory — subsequent runs (including from Claude Code) use it silently. **4. Register with Claude Code** Add to `~/.claude/settings.json` (or your project's `.claude/settings.json`): ```json { "mcpServers": { "spotify": { "command": "/absolute/path/to/mcp-spotify/.venv/bin/python", "args": ["/absolute/path/to/mcp-spotify/server.py"] } } } ``` Replace `/absolute/path/to/mcp-spotify` with the actual path on your system. Run `pwd` in the project directory to get it. ## Available Tools | Tool | Description | |---|---| | `list_playlists` | List your Spotify playlists | | `get_playlist_tracks` | All tracks in a playlist — name, artists, album, duration, URI, added date | | `list_saved_tracks` | Your liked/saved tracks | | `search_tracks` | Search Spotify for tracks by query | | `create_playlist` | Create a new playlist | | `add_tracks_to_playlist` | Add tracks to a playlist by URI | ```` - [ ] **Step 4: Commit** ```bash git add README.md git commit -m "docs: add README with setup and Claude Code registration instructions" ```