From 2ae10daef55064326181d555795443b189a99f49 Mon Sep 17 00:00:00 2001 From: Christophe Vila Date: Mon, 25 May 2026 21:34:23 +0200 Subject: [PATCH] added missing files --- .idea/.gitignore | 10 + .../plans/2026-05-22-spotify-mcp.md | 581 ++++++++++++++++++ .../plans/2026-05-25-bpm-enrichment.md | 182 ++++++ .../specs/2026-05-22-spotify-mcp-design.md | 130 ++++ 4 files changed, 903 insertions(+) create mode 100644 .idea/.gitignore create mode 100644 docs/superpowers/plans/2026-05-22-spotify-mcp.md create mode 100644 docs/superpowers/plans/2026-05-25-bpm-enrichment.md create mode 100644 docs/superpowers/specs/2026-05-22-spotify-mcp-design.md diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..30cf57e --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,10 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Ignored default folder with query files +/queries/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/docs/superpowers/plans/2026-05-22-spotify-mcp.md b/docs/superpowers/plans/2026-05-22-spotify-mcp.md new file mode 100644 index 0000000..b85f6f5 --- /dev/null +++ b/docs/superpowers/plans/2026-05-22-spotify-mcp.md @@ -0,0 +1,581 @@ +# 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" +``` diff --git a/docs/superpowers/plans/2026-05-25-bpm-enrichment.md b/docs/superpowers/plans/2026-05-25-bpm-enrichment.md new file mode 100644 index 0000000..abef04f --- /dev/null +++ b/docs/superpowers/plans/2026-05-25-bpm-enrichment.md @@ -0,0 +1,182 @@ +# BPM Enrichment 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:** Add a `bpm` field (tempo from Spotify audio-features) to every track object returned by the MCP server. + +**Architecture:** A private `_enrich_with_bpm(sp, tracks)` helper in `spotify_client.py` batch-fetches audio features for a list of parsed track dicts (100 IDs per API call) and adds a `bpm` field in place. Each of the three track-returning functions stores the track `id` during parsing, then calls this helper before returning. + +**Tech Stack:** Python 3.11+, spotipy (`sp.audio_features()`), no new dependencies. + +--- + +### Task 1: Add `_enrich_with_bpm` helper + +**Files:** +- Modify: `spotify_client.py` — add helper after `_format_duration` + +- [ ] **Step 1: Add the helper function** + +In `spotify_client.py`, insert the following function immediately after `_format_duration` (around line 58): + +```python +def _enrich_with_bpm(sp: spotipy.Spotify, tracks: list[dict]) -> None: + ids = [t["id"] for t in tracks] + for i in range(0, len(ids), 100): + chunk_ids = ids[i : i + 100] + features = sp.audio_features(chunk_ids) + for track, feat in zip(tracks[i : i + 100], features): + track["bpm"] = round(feat["tempo"], 1) if feat else None +``` + +- [ ] **Step 2: Verify syntax** + +```bash +cd /Users/kriss/Dev/scm/scm.vilanet.fr/kriss/mcp-spotify +.venv/bin/python -c "import spotify_client; print('OK')" +``` + +Expected: `OK` + +--- + +### Task 2: Add `id` field and BPM enrichment to `get_playlist_tracks` + +**Files:** +- Modify: `spotify_client.py:get_playlist_tracks` + +- [ ] **Step 1: Add `"id"` to the track dict and call the helper** + +In `get_playlist_tracks`, add `"id": track["id"],` to the `results.append({...})` block (alongside `"uri"`), and add `_enrich_with_bpm(sp, results)` just before the `return` statement: + +```python + 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.get("popularity"), + "id": track["id"], + "uri": track["uri"], + "added_at": item["added_at"], + }) + page += 1 + response = sp.next(response) if response["next"] else None + _enrich_with_bpm(sp, results) + _dbg(f"get_playlist_tracks returning {len(results)} tracks") + return results +``` + +- [ ] **Step 2: Smoke test** + +```bash +SPOTIPY_CLIENT_ID=d66baed203d1461a860acbc5db27e3f5 \ +SPOTIPY_CLIENT_SECRET=6d9ccf95957749ffac433919b585f4ff \ +SPOTIPY_REDIRECT_URI=http://127.0.0.1:8888/callback \ +.venv/bin/python -c " +import spotify_client, json +tracks = spotify_client.get_playlist_tracks('7LhqpCM88rPqDqxTLadLmf') +for t in tracks[:3]: + print(t['name'], '|', t.get('bpm'), 'bpm') +" 2>/dev/null +``` + +Expected: 3 lines like `oh baby | 128.0 bpm` + +--- + +### Task 3: Add `id` field and BPM enrichment to `list_saved_tracks` + +**Files:** +- Modify: `spotify_client.py:list_saved_tracks` + +- [ ] **Step 1: Add `"id"` to the track dict and call the helper** + +In `list_saved_tracks`, add `"id": track["id"],` to the `results.append({...})` block, and add `_enrich_with_bpm(sp, results)` just before the `return` statement: + +```python + 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"], + "id": track["id"], + "uri": track["uri"], + "added_at": item["added_at"], + }) + _enrich_with_bpm(sp, results) + return results +``` + +- [ ] **Step 2: Smoke test** + +```bash +SPOTIPY_CLIENT_ID=d66baed203d1461a860acbc5db27e3f5 \ +SPOTIPY_CLIENT_SECRET=6d9ccf95957749ffac433919b585f4ff \ +SPOTIPY_REDIRECT_URI=http://127.0.0.1:8888/callback \ +.venv/bin/python -c " +import spotify_client +tracks = spotify_client.list_saved_tracks(limit=3) +for t in tracks: + print(t['name'], '|', t.get('bpm'), 'bpm') +" 2>/dev/null +``` + +Expected: 3 lines with bpm values. + +--- + +### Task 4: Add `id` field and BPM enrichment to `search_tracks` + +**Files:** +- Modify: `spotify_client.py:search_tracks` + +- [ ] **Step 1: Add `"id"` to the track dict and call the helper** + +In `search_tracks`, add `"id": track["id"],` to the `results.append({...})` block, and add `_enrich_with_bpm(sp, results)` just before the `return` statement: + +```python + 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"], + "id": track["id"], + "uri": track["uri"], + }) + _enrich_with_bpm(sp, results) + return results +``` + +- [ ] **Step 2: Smoke test** + +```bash +SPOTIPY_CLIENT_ID=d66baed203d1461a860acbc5db27e3f5 \ +SPOTIPY_CLIENT_SECRET=6d9ccf95957749ffac433919b585f4ff \ +SPOTIPY_REDIRECT_URI=http://127.0.0.1:8888/callback \ +.venv/bin/python -c " +import spotify_client +tracks = spotify_client.search_tracks('LCD Soundsystem', limit=3) +for t in tracks: + print(t['name'], '|', t.get('bpm'), 'bpm') +" 2>/dev/null +``` + +Expected: 3 lines with bpm values. + +--- + +### Task 5: Commit + +- [ ] **Commit all changes** + +```bash +git add spotify_client.py +git commit -m "feat: add bpm field to all track-returning tools via audio-features endpoint" +``` diff --git a/docs/superpowers/specs/2026-05-22-spotify-mcp-design.md b/docs/superpowers/specs/2026-05-22-spotify-mcp-design.md new file mode 100644 index 0000000..56e7ae6 --- /dev/null +++ b/docs/superpowers/specs/2026-05-22-spotify-mcp-design.md @@ -0,0 +1,130 @@ +# Spotify MCP Server — Design Spec + +**Date:** 2026-05-22 + +## Overview + +A Python MCP server that exposes Spotify library and playlist management as tools for Claude. Connects via stdio transport (Claude launches it as a subprocess). Authentication uses the OAuth PKCE flow via `spotipy`; tokens are cached locally and auto-refreshed. + +## Project Structure + +``` +mcp-spotify/ +├── server.py # MCP server entry point, tool registrations +├── spotify_client.py # Thin wrapper around spotipy (auth + API calls) +├── pyproject.toml # Dependencies: mcp, spotipy, python-dotenv +├── .env.example # SPOTIPY_CLIENT_ID, SPOTIPY_CLIENT_SECRET, SPOTIPY_REDIRECT_URI +└── README.md # Setup instructions (creating a Spotify Developer app, registering with Claude Code) +``` + +## Dependencies + +- `mcp` — official MCP Python SDK (stdio server) +- `spotipy` — Spotify Web API client with built-in OAuth and token caching +- `python-dotenv` — load `.env` at startup + +## MCP Tools + +| Tool | Description | Parameters | +|---|---|---| +| `list_playlists` | List the authenticated user's playlists | — | +| `get_playlist_tracks` | Get all tracks in a playlist with full metadata | `playlist_id: str` | +| `list_saved_tracks` | Get the user's liked/saved tracks | `limit: int = 50` | +| `search_tracks` | Search Spotify for tracks | `query: str`, `limit: int = 10` | +| `create_playlist` | Create a new playlist | `name: str`, `description: str = ""`, `public: bool = False` | +| `add_tracks_to_playlist` | Add tracks to a playlist by URI | `playlist_id: str`, `track_uris: list[str]` | + +### Track data returned by `get_playlist_tracks` + +Each track entry includes: +- `name` — track title +- `artists` — list of artist names +- `album` — album name +- `duration_ms` — duration in milliseconds +- `duration` — duration formatted as `mm:ss` +- `popularity` — Spotify popularity score (0–100) +- `uri` — Spotify track URI (used to add tracks to playlists) +- `added_at` — ISO timestamp of when the track was added to the playlist + +All fields come from a single paginated API call (`playlist_items`); no additional requests needed. + +## Authentication + +### Setup (once per user) + +1. Create a Spotify Developer app at [developer.spotify.com](https://developer.spotify.com) +2. Set the redirect URI to `http://localhost:8888/callback` +3. Copy `.env.example` to `.env` and fill in: + ``` + SPOTIPY_CLIENT_ID= + SPOTIPY_CLIENT_SECRET= + SPOTIPY_REDIRECT_URI=http://localhost:8888/callback + ``` + +### First run + +`spotify_client.py` initializes `spotipy.Spotify` with `SpotifyOAuth`. spotipy opens the browser to Spotify's auth page. After login, Spotify redirects to `localhost:8888/callback`. spotipy exchanges the code for tokens and caches them to `~/.cache/spotipy/.cache`. + +### Subsequent runs + +spotipy reads the cached token. If expired, it silently refreshes using the stored refresh token. No browser interaction needed. + +### OAuth scopes + +``` +playlist-read-private +playlist-read-collaborative +playlist-modify-public +playlist-modify-private +user-library-read +``` + +## Architecture & Data Flow + +``` +Claude (MCP client) + │ stdio (JSON-RPC) + ▼ +server.py (mcp SDK — tool definitions + request routing) + │ Python function calls + ▼ +spotify_client.py (spotipy wrapper) + │ HTTPS + ▼ +Spotify Web API +``` + +`server.py` owns all MCP concerns: tool schemas, request parsing, response formatting. +`spotify_client.py` owns all Spotify concerns: auth, API calls, pagination, data shaping. + +## Error Handling + +- **Startup:** If required env vars are missing, raise immediately with a descriptive message before the server accepts any connections. +- **API errors:** `spotipy` raises `SpotifyException` for all API-level errors. These are caught in `server.py` and returned as MCP error responses containing the HTTP status code and Spotify error message. +- **Token expiry:** Handled transparently by spotipy's `SpotifyOAuth` — no special handling needed. + +## Claude Code Registration + +Add to `~/.claude/settings.json` (or project `.claude/settings.json`): + +```json +{ + "mcpServers": { + "spotify": { + "command": "python", + "args": ["/path/to/mcp-spotify/server.py"], + "env": { + "SPOTIPY_CLIENT_ID": "", + "SPOTIPY_CLIENT_SECRET": "", + "SPOTIPY_REDIRECT_URI": "http://localhost:8888/callback" + } + } + } +} +``` + +Alternatively, env vars can be loaded from `.env` by `python-dotenv` and the `env` block omitted. + +## Testing + +No automated tests. The server is a thin adapter over `spotipy` and the Spotify Web API; mocking the API would test the wrong thing. Verification is done by running the server and calling tools from Claude.