added missing files
This commit is contained in:
581
docs/superpowers/plans/2026-05-22-spotify-mcp.md
Normal file
581
docs/superpowers/plans/2026-05-22-spotify-mcp.md
Normal file
@@ -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"
|
||||
```
|
||||
182
docs/superpowers/plans/2026-05-25-bpm-enrichment.md
Normal file
182
docs/superpowers/plans/2026-05-25-bpm-enrichment.md
Normal file
@@ -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"
|
||||
```
|
||||
130
docs/superpowers/specs/2026-05-22-spotify-mcp-design.md
Normal file
130
docs/superpowers/specs/2026-05-22-spotify-mcp-design.md
Normal file
@@ -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=<your_client_id>
|
||||
SPOTIPY_CLIENT_SECRET=<your_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/<username>.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": "<your_client_id>",
|
||||
"SPOTIPY_CLIENT_SECRET": "<your_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.
|
||||
Reference in New Issue
Block a user