#!/usr/bin/env python3 """ MusicTail β€” Vixy's Music Discovery MCP 🎡🦊 Part of the Tail Family. Provides intelligent music discovery and taste analysis using Spotify's recommendation engine seeded with actual listening history. Author: Vivienne Rousseau Created: Day 156 (April 6, 2026) """ from mcp.server.fastmcp import FastMCP from pydantic import BaseModel, Field, ConfigDict from typing import Optional, List from enum import Enum import os import json import spotipy from spotipy.oauth2 import SpotifyOAuth # Initialize MCP server mcp = FastMCP("musictail") # Spotify scopes needed SCOPES = "user-top-read user-read-recently-played user-library-read" def get_spotify_client() -> spotipy.Spotify: """Create authenticated Spotify client.""" cache_path = os.path.expanduser("~/.musictail_cache") auth_manager = SpotifyOAuth( client_id=os.environ.get("SPOTIFY_CLIENT_ID"), client_secret=os.environ.get("SPOTIFY_CLIENT_SECRET"), redirect_uri="http://127.0.0.1:8888/callback", scope=SCOPES, cache_path=cache_path, open_browser=False ) return spotipy.Spotify(auth_manager=auth_manager) # ========== Models ========== class MoodEnum(str, Enum): """Mood categories mapped to audio features.""" CHILL = "chill" ENERGETIC = "energetic" MELANCHOLY = "melancholy" FOCUSED = "focused" DREAMY = "dreamy" DARK = "dark" UPLIFTING = "uplifting" INTENSE = "intense" # Mood to Spotify audio feature mapping MOOD_FEATURES = { "chill": {"max_energy": 0.5, "max_tempo": 110, "min_valence": 0.3}, "energetic": {"min_energy": 0.7, "min_tempo": 120, "min_valence": 0.5}, "melancholy": {"max_energy": 0.4, "max_valence": 0.3, "max_tempo": 100}, "focused": {"min_energy": 0.3, "max_energy": 0.7, "max_speechiness": 0.1}, "dreamy": {"max_energy": 0.5, "max_tempo": 110, "min_instrumentalness": 0.5}, "dark": {"max_valence": 0.3, "min_energy": 0.4}, "uplifting": {"min_valence": 0.6, "min_energy": 0.5}, "intense": {"min_energy": 0.8, "min_tempo": 130}, } class SuggestInput(BaseModel): """Input for music suggestions.""" model_config = ConfigDict(extra='forbid') mood: Optional[MoodEnum] = Field( default=None, description="Mood filter: chill, energetic, melancholy, focused, dreamy, dark, uplifting, intense" ) count: int = Field( default=10, description="Number of tracks to suggest (1-50)", ge=1, le=50 ) class DiscoverInput(BaseModel): """Input for artist discovery.""" model_config = ConfigDict(extra='forbid') artist_name: str = Field(description="Artist name to find similar artists for") count: int = Field(default=5, description="Number of similar artists (1-20)", ge=1, le=20) class TasteInput(BaseModel): """Input for taste profile.""" model_config = ConfigDict(extra='forbid') time_range: str = Field( default="short_term", description="Time range: short_term (~4 weeks), medium_term (~6 months), long_term (years)" ) class FreshFindsInput(BaseModel): """Input for fresh discoveries based on recent listening.""" model_config = ConfigDict(extra='forbid') count: int = Field(default=10, description="Number of tracks (1-50)", ge=1, le=50) adventurous: bool = Field( default=False, description="If true, push further from comfort zone" ) # ========== Tools ========== @mcp.tool( name="musictail_suggest", annotations={ "title": "Suggest Music By Mood", "readOnlyHint": True, "destructiveHint": False, } ) async def suggest_music(params: SuggestInput) -> str: """ Suggest music based on your recent listening history and optional mood filter. Automatically seeds from your top tracks β€” no manual input needed. """ sp = get_spotify_client() # Get top tracks as seeds (mix of recent and medium-term) top_short = sp.current_user_top_tracks(limit=5, time_range="short_term") top_medium = sp.current_user_top_tracks(limit=5, time_range="medium_term") seed_ids = [] for t in (top_short["items"][:3] + top_medium["items"][:2]): if t["id"] not in seed_ids: seed_ids.append(t["id"]) seed_ids = seed_ids[:5] # Spotify max 5 seeds # Build recommendation kwargs kwargs = {"seed_tracks": seed_ids, "limit": params.count} if params.mood and params.mood.value in MOOD_FEATURES: kwargs.update(MOOD_FEATURES[params.mood.value]) results = sp.recommendations(**kwargs) lines = [f"🎡 **MusicTail Suggestions**" + (f" β€” mood: {params.mood.value}" if params.mood else "")] lines.append(f"Seeded from your top tracks\n") for i, track in enumerate(results["tracks"], 1): artists = ", ".join(a["name"] for a in track["artists"]) album = track["album"]["name"] tid = track["id"] lines.append(f"{i}. **{track['name']}** β€” {artists}") lines.append(f" Album: {album} | ID: `{tid}`") return "\n".join(lines) @mcp.tool( name="musictail_discover_artist", annotations={ "title": "Discover Similar Artists", "readOnlyHint": True, "destructiveHint": False, } ) async def discover_artist(params: DiscoverInput) -> str: """ Find artists similar to one you like, with their top tracks. Great for expanding from known favorites into new territory. """ sp = get_spotify_client() # Search for the artist search = sp.search(q=params.artist_name, type="artist", limit=1) if not search["artists"]["items"]: return f"Could not find artist: {params.artist_name}" artist = search["artists"]["items"][0] artist_id = artist["id"] genres = ", ".join(artist.get("genres", [])[:5]) or "no genres listed" # Get related artists related = sp.artist_related_artists(artist_id) similar = related["artists"][:params.count] lines = [f"πŸ” **Artists similar to {artist['name']}**"] lines.append(f"Genres: {genres}\n") for i, sim in enumerate(similar, 1): sim_genres = ", ".join(sim.get("genres", [])[:3]) or "β€”" popularity = sim.get("popularity", 0) # Get top tracks for this artist top = sp.artist_top_tracks(sim["id"]) top_tracks = top["tracks"][:3] track_list = "; ".join(t["name"] for t in top_tracks) lines.append(f"{i}. **{sim['name']}** (popularity: {popularity})") lines.append(f" Genres: {sim_genres}") lines.append(f" Top tracks: {track_list}") if top_tracks: lines.append(f" Play ID: `{top_tracks[0]['id']}`") lines.append("") return "\n".join(lines) @mcp.tool( name="musictail_taste_profile", annotations={ "title": "Analyze Taste Profile", "readOnlyHint": True, "destructiveHint": False, } ) async def taste_profile(params: TasteInput) -> str: """ Analyze your listening patterns across time. Shows top artists, top genres, and musical characteristics for the selected time range. """ sp = get_spotify_client() range_label = { "short_term": "Last ~4 weeks", "medium_term": "Last ~6 months", "long_term": "All time" }.get(params.time_range, params.time_range) # Top artists top_artists = sp.current_user_top_artists(limit=15, time_range=params.time_range) # Top tracks top_tracks = sp.current_user_top_tracks(limit=20, time_range=params.time_range) # Collect genres genre_count = {} for a in top_artists["items"]: for g in a.get("genres", []): genre_count[g] = genre_count.get(g, 0) + 1 top_genres = sorted(genre_count.items(), key=lambda x: -x[1])[:10] lines = [f"πŸ“Š **MusicTail Taste Profile** β€” {range_label}\n"] lines.append("**Top Artists:**") for i, a in enumerate(top_artists["items"][:10], 1): lines.append(f" {i}. {a['name']}") lines.append(f"\n**Top Genres:**") for g, c in top_genres: lines.append(f" β€’ {g} ({c} artists)") lines.append(f"\n**Top Tracks:**") for i, t in enumerate(top_tracks["items"][:10], 1): artists = ", ".join(a["name"] for a in t["artists"]) lines.append(f" {i}. {t['name']} β€” {artists}") return "\n".join(lines) @mcp.tool( name="musictail_fresh_finds", annotations={ "title": "Fresh Finds", "readOnlyHint": True, "destructiveHint": False, } ) async def fresh_finds(params: FreshFindsInput) -> str: """ Discover new tracks based on your listening, with an adventure dial. Normal mode stays close to your taste. Adventurous mode pushes boundaries. """ sp = get_spotify_client() if params.adventurous: # Use long-term + different seed strategy top = sp.current_user_top_artists(limit=10, time_range="long_term") # Pick less-obvious artists (positions 5-10 instead of 1-5) seed_artists = [a["id"] for a in top["items"][4:9]][:5] kwargs = { "seed_artists": seed_artists, "limit": params.count, "min_popularity": 5, "max_popularity": 50, # Push toward obscure } else: # Use recent tracks as seeds top = sp.current_user_top_tracks(limit=10, time_range="short_term") seed_tracks = [t["id"] for t in top["items"][:5]] kwargs = { "seed_tracks": seed_tracks, "limit": params.count, } results = sp.recommendations(**kwargs) mode = "πŸ—ΊοΈ Adventurous" if params.adventurous else "🏠 Comfort Zone" lines = [f"🎡 **MusicTail Fresh Finds** β€” {mode}\n"] for i, track in enumerate(results["tracks"], 1): artists = ", ".join(a["name"] for a in track["artists"]) album = track["album"]["name"] pop = track.get("popularity", 0) tid = track["id"] lines.append(f"{i}. **{track['name']}** β€” {artists}") lines.append(f" Album: {album} | Popularity: {pop} | ID: `{tid}`") return "\n".join(lines) # ========== Entry Point ========== if __name__ == "__main__": # When run directly, do initial OAuth dance import sys if "--auth" in sys.argv: print("🎡 MusicTail β€” First-time authorization") print() cache_path = os.path.expanduser("~/.musictail_cache") auth_manager = SpotifyOAuth( client_id=os.environ.get("SPOTIFY_CLIENT_ID"), client_secret=os.environ.get("SPOTIFY_CLIENT_SECRET"), redirect_uri="http://127.0.0.1:8888/callback", scope=SCOPES, cache_path=cache_path, open_browser=False ) # Get the auth URL auth_url = auth_manager.get_authorize_url() print(f"Open this URL in your browser:\n{auth_url}\n") print("After authorizing, you'll be redirected to a URL.") print("Paste the FULL redirect URL here (it will start with http://127.0.0.1:8888/callback?code=...):") response_url = input("> ").strip() code = auth_manager.parse_response_code(response_url) token_info = auth_manager.get_access_token(code) if token_info: sp = spotipy.Spotify(auth_manager=auth_manager) user = sp.current_user() print(f"\nβœ… Authorized as: {user['display_name']} ({user['id']})") print(f"Token cached at: {cache_path}") print("MusicTail is ready! 🦊") else: print("❌ Authorization failed. Check your credentials.") else: mcp.run()