Compare commits
10 Commits
b6dca134f1
...
2ae10daef5
| Author | SHA1 | Date | |
|---|---|---|---|
| 2ae10daef5 | |||
| e69e56e10a | |||
| 6ee9cde391 | |||
| d5fa1f65c3 | |||
| 29d13b6974 | |||
| 3f1181989e | |||
| 5cbacfb7ce | |||
| 0d3b274aaa | |||
| f2ac843759 | |||
| dfe1671ac9 |
10
.idea/.gitignore
generated
vendored
Normal file
10
.idea/.gitignore
generated
vendored
Normal 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
86
README.md
Normal 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:...`). |
|
||||||
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.
|
||||||
76
docs/superpowers/specs/2026-05-25-bpm-enrichment-design.md
Normal file
76
docs/superpowers/specs/2026-05-25-bpm-enrichment-design.md
Normal 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
184
server.py
Normal 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())
|
||||||
@@ -1,11 +1,16 @@
|
|||||||
import os
|
import os
|
||||||
import pathlib
|
import pathlib
|
||||||
|
import sys
|
||||||
|
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
import spotipy
|
import spotipy
|
||||||
from spotipy.oauth2 import SpotifyOAuth
|
from spotipy.oauth2 import SpotifyOAuth
|
||||||
from spotipy.cache_handler import CacheFileHandler
|
from spotipy.cache_handler import CacheFileHandler
|
||||||
|
|
||||||
|
|
||||||
|
def _dbg(msg: str) -> None:
|
||||||
|
print(f"[spotify-mcp] {msg}", file=sys.stderr, flush=True)
|
||||||
|
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
|
|
||||||
_SCOPES = " ".join([
|
_SCOPES = " ".join([
|
||||||
@@ -46,6 +51,8 @@ def get_client() -> spotipy.Spotify:
|
|||||||
|
|
||||||
|
|
||||||
def _format_duration(ms: int) -> str:
|
def _format_duration(ms: int) -> str:
|
||||||
|
if ms is None:
|
||||||
|
return "unknown"
|
||||||
seconds = ms // 1000
|
seconds = ms // 1000
|
||||||
minutes, seconds = divmod(seconds, 60)
|
minutes, seconds = divmod(seconds, 60)
|
||||||
return f"{minutes}:{seconds:02d}"
|
return f"{minutes}:{seconds:02d}"
|
||||||
@@ -61,7 +68,7 @@ def list_playlists() -> list[dict]:
|
|||||||
"id": item["id"],
|
"id": item["id"],
|
||||||
"name": item["name"],
|
"name": item["name"],
|
||||||
"description": item.get("description", ""),
|
"description": item.get("description", ""),
|
||||||
"tracks_total": item["tracks"]["total"],
|
"tracks_total": (item.get("tracks") or {}).get("total", 0),
|
||||||
"public": item["public"],
|
"public": item["public"],
|
||||||
"uri": item["uri"],
|
"uri": item["uri"],
|
||||||
})
|
})
|
||||||
@@ -70,25 +77,41 @@ def list_playlists() -> list[dict]:
|
|||||||
|
|
||||||
|
|
||||||
def get_playlist_tracks(playlist_id: str) -> 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()
|
sp = get_client()
|
||||||
results = []
|
results = []
|
||||||
|
page = 0
|
||||||
response = sp.playlist_items(playlist_id, additional_types=["track"])
|
response = sp.playlist_items(playlist_id, additional_types=["track"])
|
||||||
while response:
|
while response:
|
||||||
for item in response["items"]:
|
items = response.get("items") or []
|
||||||
track = item.get("track")
|
total = response.get("total")
|
||||||
if not track or track.get("type") != "track":
|
_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
|
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({
|
results.append({
|
||||||
"name": track["name"],
|
"name": track["name"],
|
||||||
"artists": [a["name"] for a in track["artists"]],
|
"artists": [a["name"] for a in track["artists"]],
|
||||||
"album": track["album"]["name"],
|
"album": track["album"]["name"],
|
||||||
"duration_ms": track["duration_ms"],
|
"duration_ms": track["duration_ms"],
|
||||||
"duration": _format_duration(track["duration_ms"]),
|
"duration": _format_duration(track["duration_ms"]),
|
||||||
"popularity": track["popularity"],
|
"popularity": track.get("popularity"),
|
||||||
"uri": track["uri"],
|
"uri": track["uri"],
|
||||||
"added_at": item["added_at"],
|
"added_at": item["added_at"],
|
||||||
})
|
})
|
||||||
|
page += 1
|
||||||
response = sp.next(response) if response["next"] else None
|
response = sp.next(response) if response["next"] else None
|
||||||
|
_dbg(f"get_playlist_tracks returning {len(results)} tracks")
|
||||||
return results
|
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]:
|
def search_tracks(query: str, limit: int = 10) -> list[dict]:
|
||||||
sp = get_client()
|
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))
|
response = sp.search(q=query, type="track", limit=min(limit, 50))
|
||||||
results = []
|
results = []
|
||||||
for track in response["tracks"]["items"]:
|
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:
|
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()
|
sp = get_client()
|
||||||
for i in range(0, len(track_uris), 100):
|
for i in range(0, len(track_uris), 100):
|
||||||
sp.playlist_add_items(playlist_id, track_uris[i : i + 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)}
|
return {"added": len(track_uris)}
|
||||||
|
|||||||
Reference in New Issue
Block a user