Files
mcp-spotify/docs/superpowers/plans/2026-05-22-spotify-mcp.md
2026-05-25 21:34:23 +02:00

17 KiB

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

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

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

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
source .venv/bin/activate
python -c "import server; print('OK')"

Expected: OK

  • Step 3: Commit
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:

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
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
# 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
git add README.md
git commit -m "docs: add README with setup and Claude Code registration instructions"