Compare commits

..

10 Commits

Author SHA1 Message Date
2ae10daef5 added missing files 2026-05-25 21:34:23 +02:00
e69e56e10a docs: add BPM enrichment design spec 2026-05-25 18:33:10 +02:00
6ee9cde391 fix: read track from item["item"] key to match updated Spotify API response shape
Spotify's playlist_items endpoint now nests the track object under "item"
instead of "track" in each playlist entry. The old key is absent, causing
item.get("track") to return None for every entry and silently drop all tracks.

Also adds stderr debug traces at each component boundary (tool dispatch,
API page summary, per-item accept/skip) to make future silent-empty issues
diagnosable, and switches popularity to .get() since it is absent in the
new response shape.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 18:27:31 +02:00
d5fa1f65c3 fix: guard against missing tracks field in list_playlists 2026-05-25 15:32:50 +02:00
29d13b6974 fix: empty track_uris guard, None duration handling, README env block 2026-05-22 22:41:13 +02:00
3f1181989e fix: correct env var names and add redirect URI to setup guide 2026-05-22 22:38:03 +02:00
5cbacfb7ce docs: add README with setup and Claude Code registration instructions 2026-05-22 22:36:34 +02:00
0d3b274aaa fix: verify token on --auth and broaden startup exception catch 2026-05-22 22:35:25 +02:00
f2ac843759 feat: implement MCP server with 6 Spotify tools 2026-05-22 22:28:50 +02:00
dfe1671ac9 fix: clarify search cap and batch completion semantics 2026-05-22 22:27:14 +02:00
8 changed files with 1281 additions and 5 deletions

10
.idea/.gitignore generated vendored Normal file
View File

@@ -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

86
README.md Normal file
View File

@@ -0,0 +1,86 @@
# mcp-spotify
Python MCP server exposing Spotify library and playlist management as tools for Claude.
## Prerequisites
- **Python 3.11+**
- **Spotify Developer Account** with an app registered at [Spotify Developer Dashboard](https://developer.spotify.com/dashboard)
- Note the Client ID and Client Secret from your app
- Set the Redirect URI to `http://localhost:8888/callback` in your app settings
## Setup
### Step 1: Install dependencies
```bash
python3 -m venv .venv
source .venv/bin/activate # On Windows: .venv\Scripts\activate
pip install -e .
```
### Step 2: Configure credentials
```bash
cp .env.example .env
```
Edit `.env` and fill in your Spotify credentials:
```
SPOTIPY_CLIENT_ID=your_client_id_here
SPOTIPY_CLIENT_SECRET=your_client_secret_here
SPOTIPY_REDIRECT_URI=http://localhost:8888/callback
```
### Step 3: Authenticate with Spotify
Run the server once with the `--auth` flag to authenticate with Spotify. This will open a browser window where you authorize the app to access your Spotify account. The access token is cached locally at `.cache` in the project directory for subsequent runs.
```bash
python server.py --auth
```
You should see: `Authentication successful! Token cached.`
### Step 4: Register with Claude Code
Add the MCP server to your Claude Code configuration by editing `~/.claude/settings.json`:
````json
{
"mcpServers": {
"spotify": {
"command": "/absolute/path/to/mcp-spotify/.venv/bin/python",
"args": ["/absolute/path/to/mcp-spotify/server.py"],
"env": {
"SPOTIPY_CLIENT_ID": "your_client_id_here",
"SPOTIPY_CLIENT_SECRET": "your_client_secret_here",
"SPOTIPY_REDIRECT_URI": "http://localhost:8888/callback"
}
}
}
}
````
**Note:** Replace the path and fill in the env values with your actual Spotify app credentials. The `env` block is required because Claude Code launches the server as a subprocess from a different working directory where `load_dotenv()` may not find your `.env` file.
Replace `/absolute/path/to/mcp-spotify` with the actual path to your project. To find it, run `pwd` in the project directory:
```bash
cd /path/to/mcp-spotify
pwd
```
Then update the paths in `settings.json` accordingly. On Windows, the python path is `.venv\Scripts\python.exe`.
## Available Tools
| Tool | Description |
|------|-------------|
| `list_playlists` | List the authenticated user's Spotify playlists. |
| `get_playlist_tracks` | Get all tracks in a Spotify playlist with full metadata: name, artists, album, duration, popularity, URI, and added_at. |
| `list_saved_tracks` | Get the user's liked/saved tracks from Spotify. Optional limit parameter (default 50). |
| `search_tracks` | Search Spotify for tracks. Returns track URIs that can be passed to add_tracks_to_playlist. |
| `create_playlist` | Create a new Spotify playlist for the authenticated user. Takes name, optional description, and public flag. |
| `add_tracks_to_playlist` | Add tracks to a Spotify playlist by their URIs (e.g., `spotify:track:...`). |

View 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"
```

View 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"
```

View 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 (0100)
- `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.

View File

@@ -0,0 +1,76 @@
# BPM Enrichment Design
**Date:** 2026-05-25
**Status:** Approved
## Goal
Add a `bpm` field to every track object returned by the MCP server, sourced from Spotify's `/audio-features` endpoint.
## Scope
Three tools return track objects and must be updated:
- `get_playlist_tracks`
- `list_saved_tracks`
- `search_tracks`
No new MCP tools. No changes to `server.py`.
## Architecture
### New helper: `_enrich_with_bpm(sp, tracks)`
Location: `spotify_client.py`
- Takes a `spotipy.Spotify` client and a list of already-parsed track dicts.
- Extracts track IDs from each dict's `"id"` field.
- Slices into chunks of 100 (API limit) and calls `sp.audio_features(ids)` once per chunk.
- Matches results back by position and sets `track["bpm"]` to the rounded tempo float.
- If the API returns `None` for a track (features unavailable), sets `bpm` to `null`.
- Mutates the list in place; returns nothing.
### Changes to track parsers
Each of the three functions adds `"id": track["id"]` to the dict it builds (currently absent from the output). After the full track list is assembled, each function calls `_enrich_with_bpm(sp, results)` before returning.
## Data Shape
Before:
```json
{
"name": "oh baby",
"artists": ["LCD Soundsystem"],
"album": "american dream",
"duration_ms": 349452,
"duration": "5:49",
"popularity": null,
"uri": "spotify:track:53PkA8aXiwH4ppa0V0iO7o",
"added_at": "2026-05-22T17:41:18Z"
}
```
After:
```json
{
"name": "oh baby",
"artists": ["LCD Soundsystem"],
"album": "american dream",
"duration_ms": 349452,
"duration": "5:49",
"popularity": null,
"uri": "spotify:track:53PkA8aXiwH4ppa0V0iO7o",
"id": "53PkA8aXiwH4ppa0V0iO7o",
"added_at": "2026-05-22T17:41:18Z",
"bpm": 128.0
}
```
## Error Handling
- `sp.audio_features()` may return `None` entries for tracks with no features (rare, e.g. local files, some podcasts). These get `bpm: null`.
- Any exception from `sp.audio_features()` propagates naturally — same behaviour as the rest of the client.
## API Cost
- `get_playlist_tracks` / `list_saved_tracks`: 1 extra API call per 100 tracks (batched).
- `search_tracks`: at most 1 extra call (max 50 results per search).

184
server.py Normal file
View File

@@ -0,0 +1,184 @@
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
def _dbg(msg: str) -> None:
print(f"[spotify-mcp] {msg}", file=sys.stderr, flush=True)
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 {}
_dbg(f"tool={name!r} args={arguments!r}")
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:
_dbg(f"tool={name!r} raised {type(e).__name__}: {e}")
return [TextContent(type="text", text=f"Error: {e}")]
result_len = len(result) if isinstance(result, list) else "n/a"
_dbg(f"tool={name!r} returning {result_len} items")
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)...")
sp = spotify_client.get_client()
sp.current_user() # verify the token actually works
print("Authentication successful! Token cached.")
sys.exit(0)
try:
spotify_client.get_client()
except Exception as e:
print(f"Startup error: {e}", file=sys.stderr)
sys.exit(1)
asyncio.run(_run_server())

View File

@@ -1,11 +1,16 @@
import os
import pathlib
import sys
from dotenv import load_dotenv
import spotipy
from spotipy.oauth2 import SpotifyOAuth
from spotipy.cache_handler import CacheFileHandler
def _dbg(msg: str) -> None:
print(f"[spotify-mcp] {msg}", file=sys.stderr, flush=True)
load_dotenv()
_SCOPES = " ".join([
@@ -46,6 +51,8 @@ def get_client() -> spotipy.Spotify:
def _format_duration(ms: int) -> str:
if ms is None:
return "unknown"
seconds = ms // 1000
minutes, seconds = divmod(seconds, 60)
return f"{minutes}:{seconds:02d}"
@@ -61,7 +68,7 @@ def list_playlists() -> list[dict]:
"id": item["id"],
"name": item["name"],
"description": item.get("description", ""),
"tracks_total": item["tracks"]["total"],
"tracks_total": (item.get("tracks") or {}).get("total", 0),
"public": item["public"],
"uri": item["uri"],
})
@@ -70,25 +77,41 @@ def list_playlists() -> list[dict]:
def get_playlist_tracks(playlist_id: str) -> list[dict]:
_dbg(f"get_playlist_tracks called with playlist_id={playlist_id!r}")
sp = get_client()
results = []
page = 0
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":
items = response.get("items") or []
total = response.get("total")
_dbg(f" page {page}: total={total}, items_in_page={len(items)}, next={bool(response.get('next'))}")
for idx, item in enumerate(items):
# Spotify API returns the track under "item" key (newer API) or "track" key (older)
track = item.get("item") or item.get("track")
if track is None:
_dbg(f" item[{idx}]: track=None (local/null track), skipping")
continue
track_type = track.get("type")
track_name = track.get("name", "<no-name>")
track_uri = track.get("uri", "<no-uri>")
if track_type != "track":
_dbg(f" item[{idx}]: SKIPPED type={track_type!r} name={track_name!r} uri={track_uri!r}")
continue
_dbg(f" item[{idx}]: OK type={track_type!r} name={track_name!r}")
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"],
"popularity": track.get("popularity"),
"uri": track["uri"],
"added_at": item["added_at"],
})
page += 1
response = sp.next(response) if response["next"] else None
_dbg(f"get_playlist_tracks returning {len(results)} tracks")
return results
@@ -120,6 +143,7 @@ def list_saved_tracks(limit: int = 50) -> list[dict]:
def search_tracks(query: str, limit: int = 10) -> list[dict]:
sp = get_client()
# Spotify's search endpoint returns at most 50 results per call; no pagination available
response = sp.search(q=query, type="track", limit=min(limit, 50))
results = []
for track in response["tracks"]["items"]:
@@ -153,7 +177,10 @@ def create_playlist(name: str, description: str = "", public: bool = False) -> d
def add_tracks_to_playlist(playlist_id: str, track_uris: list[str]) -> dict:
if not track_uris:
raise ValueError("track_uris must not be empty")
sp = get_client()
for i in range(0, len(track_uris), 100):
sp.playlist_add_items(playlist_id, track_uris[i : i + 100])
# Only reached if all batches succeed — any SpotifyException propagates to the caller
return {"added": len(track_uris)}