"""Spotify MCP server (vendored into OpenSwarm). Spotify Web API via the `spotipy` Python client. OAuth happens in the backend's /credentials/spotify/* endpoints BEFORE this server starts; we just need a refresh_token (per-user, in env) plus the app-level client_id and client_secret (OpenSwarm-team owned, also in env). Tools cover the common asks users make in chat: Playback: play, pause, skip_next, skip_previous, queue_add, get_currently_playing, get_playback_state Library: search, get_my_playlists, get_top_tracks, get_top_artists, get_recently_played Mutate: create_playlist, add_to_playlist Playback actions require Spotify Premium (Spotify's API rule, not ours). Search and library reads work on Free. """ from __future__ import annotations import logging import os import sys from typing import Any, Dict, List, Optional from mcp.server.fastmcp import FastMCP import spotipy from spotipy.oauth2 import SpotifyOAuth, SpotifyOauthError logger = logging.getLogger(__name__) logger.setLevel(logging.DEBUG) INSTRUCTIONS = """ Spotify via the Web API. 14 tools across search, playback, queue, library reads, and playlist mutation. Playback actions require Spotify Premium. Per-tool rate limiting is enforced by OpenSwarm. """ mcp = FastMCP(name="Spotify", instructions=INSTRUCTIONS) # Lazy-init singleton client. spotipy threads connection state internally, # so we share one across tool calls. _client: Optional[spotipy.Spotify] = None def _scopes() -> str: """Space-separated Spotify scopes the agent operates under.""" return " ".join([ "user-read-currently-playing", "user-read-playback-state", "user-modify-playback-state", "user-read-recently-played", "user-top-read", "playlist-read-private", "playlist-read-collaborative", "playlist-modify-public", "playlist-modify-private", "user-library-read", "user-library-modify", ]) def _get_client() -> spotipy.Spotify: """Build the singleton spotipy client from env. Refresh-token flow keeps a valid access_token cached internally; tokens auto-rotate on expiry.""" global _client if _client is not None: return _client client_id = os.environ.get("OPENSWARM_SPOTIFY_CLIENT_ID", "").strip() client_secret = os.environ.get("OPENSWARM_SPOTIFY_CLIENT_SECRET", "").strip() refresh_token = os.environ.get("SPOTIFY_REFRESH_TOKEN", "").strip() redirect_uri = os.environ.get( "OPENSWARM_SPOTIFY_REDIRECT_URI", "http://127.0.0.1:8888/spotify/callback", ) if not client_id or not client_secret: raise RuntimeError( "Spotify MCP missing app credentials. Set " "OPENSWARM_SPOTIFY_CLIENT_ID and OPENSWARM_SPOTIFY_CLIENT_SECRET " "in backend/.env (register an app at developer.spotify.com/dashboard)." ) if not refresh_token: raise RuntimeError( "Spotify MCP missing per-user refresh token. Connect Spotify " "via the OpenSwarm Tools page first." ) auth = SpotifyOAuth( client_id=client_id, client_secret=client_secret, redirect_uri=redirect_uri, scope=_scopes(), # No cache — we own token lifecycle via env. spotipy will use the # refresh_token to mint access tokens on demand. cache_handler=spotipy.cache_handler.MemoryCacheHandler(), open_browser=False, ) # Force a token refresh now so we fail fast if the refresh_token is stale. try: token_info = auth.refresh_access_token(refresh_token) except SpotifyOauthError as exc: raise RuntimeError(f"Spotify refresh_token rejected: {exc}. Reconnect Spotify in OpenSwarm.") # Log what scopes the token ACTUALLY carries. If a playlist or library # action errors later with "Insufficient scope", check this line first # — it shows exactly what was granted at sign-in time. granted = (token_info.get("scope") or "").split() expected = set(_scopes().split()) missing = expected - set(granted) if missing: logger.warning( f"Spotify token is missing scopes: {sorted(missing)}. " f"Some tools will fail with 'Insufficient scope'. Reconnect in OpenSwarm Tools page." ) else: logger.info(f"Spotify token has all {len(granted)} expected scopes") _client = spotipy.Spotify(auth=token_info["access_token"]) return _client def _err(exc: Exception) -> Dict[str, Any]: msg = str(exc) or type(exc).__name__ return {"success": False, "message": msg[:500]} def _track_summary(t: Dict[str, Any]) -> Dict[str, Any]: """Compact track dict suitable for an LLM context budget.""" if not t: return {} artists = ", ".join(a.get("name", "") for a in (t.get("artists") or []) if a.get("name")) return { "id": t.get("id"), "name": t.get("name"), "artists": artists, "album": (t.get("album") or {}).get("name"), "duration_ms": t.get("duration_ms"), "uri": t.get("uri"), "explicit": t.get("explicit"), } # ---- search / read -------------------------------------------------------- @mcp.tool() def search(query: str, kind: str = "track", limit: int = 10) -> Dict[str, Any]: """Search Spotify. Args: query: Free-text query. Spotify search syntax (`artist:`, `album:`) supported. kind: One of 'track', 'album', 'artist', 'playlist'. Default 'track'. limit: Max results (1-50). """ try: client = _get_client() kind = kind.lower() if kind not in ("track", "album", "artist", "playlist"): return {"success": False, "message": f"kind must be track/album/artist/playlist, got {kind!r}"} res = client.search(q=query, type=kind, limit=max(1, min(int(limit), 50))) items_key = f"{kind}s" items = (res.get(items_key) or {}).get("items") or [] if kind == "track": results = [_track_summary(t) for t in items] else: results = [{"id": i.get("id"), "name": i.get("name"), "uri": i.get("uri")} for i in items] return {"success": True, "results": results} except Exception as e: # noqa: BLE001 return _err(e) @mcp.tool() def get_currently_playing() -> Dict[str, Any]: """Return what the user is listening to right now (or null if nothing).""" try: client = _get_client() cur = client.current_user_playing_track() if not cur or not cur.get("item"): return {"success": True, "playing": False} return { "success": True, "playing": cur.get("is_playing"), "progress_ms": cur.get("progress_ms"), "track": _track_summary(cur.get("item") or {}), } except Exception as e: # noqa: BLE001 return _err(e) @mcp.tool() def get_playback_state() -> Dict[str, Any]: """Return device + shuffle + repeat + volume state.""" try: client = _get_client() s = client.current_playback() or {} device = s.get("device") or {} return { "success": True, "is_playing": s.get("is_playing"), "device_name": device.get("name"), "device_type": device.get("type"), "volume_percent": device.get("volume_percent"), "shuffle": s.get("shuffle_state"), "repeat": s.get("repeat_state"), "track": _track_summary((s.get("item") or {})) if s.get("item") else None, } except Exception as e: # noqa: BLE001 return _err(e) @mcp.tool() def get_recently_played(limit: int = 20) -> Dict[str, Any]: """Last tracks the user listened to (default 20, max 50).""" try: client = _get_client() res = client.current_user_recently_played(limit=max(1, min(int(limit), 50))) items = res.get("items") or [] return {"success": True, "tracks": [_track_summary(i.get("track") or {}) for i in items]} except Exception as e: # noqa: BLE001 return _err(e) @mcp.tool() def get_top_tracks(limit: int = 10, time_range: str = "medium_term") -> Dict[str, Any]: """User's top tracks. Args: limit: 1-50. time_range: 'short_term' (~4 weeks), 'medium_term' (~6 months), 'long_term' (years). """ try: client = _get_client() if time_range not in ("short_term", "medium_term", "long_term"): time_range = "medium_term" res = client.current_user_top_tracks(limit=max(1, min(int(limit), 50)), time_range=time_range) return {"success": True, "tracks": [_track_summary(t) for t in (res.get("items") or [])]} except Exception as e: # noqa: BLE001 return _err(e) @mcp.tool() def get_top_artists(limit: int = 10, time_range: str = "medium_term") -> Dict[str, Any]: """User's top artists. Same time_range options as get_top_tracks.""" try: client = _get_client() if time_range not in ("short_term", "medium_term", "long_term"): time_range = "medium_term" res = client.current_user_top_artists(limit=max(1, min(int(limit), 50)), time_range=time_range) artists = [{"id": a.get("id"), "name": a.get("name"), "genres": a.get("genres"), "popularity": a.get("popularity")} for a in (res.get("items") or [])] return {"success": True, "artists": artists} except Exception as e: # noqa: BLE001 return _err(e) @mcp.tool() def get_my_playlists(limit: int = 20) -> Dict[str, Any]: """List the user's playlists (owned + followed).""" try: client = _get_client() res = client.current_user_playlists(limit=max(1, min(int(limit), 50))) playlists = [{ "id": p.get("id"), "name": p.get("name"), "owner": (p.get("owner") or {}).get("display_name"), "tracks_total": (p.get("tracks") or {}).get("total"), "public": p.get("public"), "collaborative": p.get("collaborative"), } for p in (res.get("items") or [])] return {"success": True, "playlists": playlists} except Exception as e: # noqa: BLE001 return _err(e) # ---- playback (Premium-only) --------------------------------------------- @mcp.tool() def play(track_uri: Optional[str] = None, context_uri: Optional[str] = None) -> Dict[str, Any]: """Start or resume playback. Requires Spotify Premium. Args: track_uri: Spotify track URI (e.g. 'spotify:track:...') to play immediately. context_uri: Album/artist/playlist URI to play from (e.g. 'spotify:playlist:...'). If neither is set, resumes whatever was paused. """ try: client = _get_client() kwargs: Dict[str, Any] = {} if track_uri: kwargs["uris"] = [track_uri] if context_uri: kwargs["context_uri"] = context_uri client.start_playback(**kwargs) return {"success": True} except Exception as e: # noqa: BLE001 return _err(e) @mcp.tool() def pause() -> Dict[str, Any]: """Pause playback. Requires Premium.""" try: _get_client().pause_playback() return {"success": True} except Exception as e: # noqa: BLE001 return _err(e) @mcp.tool() def skip_next() -> Dict[str, Any]: """Skip to next track. Requires Premium.""" try: _get_client().next_track() return {"success": True} except Exception as e: # noqa: BLE001 return _err(e) @mcp.tool() def skip_previous() -> Dict[str, Any]: """Skip to previous track. Requires Premium.""" try: _get_client().previous_track() return {"success": True} except Exception as e: # noqa: BLE001 return _err(e) @mcp.tool() def queue_add(track_uri: str) -> Dict[str, Any]: """Add a track to the playback queue. Requires Premium. Args: track_uri: e.g. 'spotify:track:6rqhFgbbKwnb9MLmUQDhG6'. """ try: _get_client().add_to_queue(uri=track_uri) return {"success": True} except Exception as e: # noqa: BLE001 return _err(e) # ---- mutate library ------------------------------------------------------ @mcp.tool() def create_playlist(name: str, description: str = "", public: bool = False) -> Dict[str, Any]: """Create a new playlist for the user.""" try: client = _get_client() me = client.me() pl = client.user_playlist_create(user=me["id"], name=name, public=public, description=description) return {"success": True, "playlist_id": pl.get("id"), "name": pl.get("name"), "uri": pl.get("uri")} except Exception as e: # noqa: BLE001 return _err(e) @mcp.tool() def add_to_playlist(playlist_id: str, track_uris: List[str]) -> Dict[str, Any]: """Append tracks to a playlist. Args: playlist_id: Spotify playlist ID (the 22-char base62 string). track_uris: List of Spotify track URIs. """ try: if not track_uris: return {"success": False, "message": "track_uris is empty"} _get_client().playlist_add_items(playlist_id, track_uris[:100]) # API cap is 100/call return {"success": True, "added": len(track_uris[:100])} except Exception as e: # noqa: BLE001 return _err(e) # ---- main ---------------------------------------------------------------- def main() -> None: """Spawn entrypoint. Validate creds + refresh_token then start stdio loop.""" try: _get_client() except RuntimeError as e: logger.error(str(e)) sys.exit(1) logger.info("Spotify MCP ready") mcp.run(transport="stdio") if __name__ == "__main__": main()