#!/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. Uses genre-based search seeded from your top artists' genres. """ sp = get_spotify_client() # Get top artists to extract genres top_artists = sp.current_user_top_artists(limit=10, time_range="short_term") # Collect genres from top artists genres = [] for a in top_artists["items"]: genres.extend(a.get("genres", [])) # Deduplicate and pick top genres genre_count = {} for g in genres: genre_count[g] = genre_count.get(g, 0) + 1 sorted_genres = sorted(genre_count.items(), key=lambda x: -x[1]) top_genres = [g for g, _ in sorted_genres[:3]] if not top_genres: return "Could not determine your genres. Listen to more music first!" # Add mood keywords to search mood_keywords = { "chill": "chill ambient", "energetic": "energetic upbeat", "melancholy": "melancholy sad", "focused": "instrumental focus", "dreamy": "dreamy ethereal", "dark": "dark atmospheric", "uplifting": "uplifting bright", "intense": "intense powerful", } # Build search query from genres + mood query_parts = top_genres[:2] if params.mood and params.mood.value in mood_keywords: query_parts.append(mood_keywords[params.mood.value]) query = " ".join(query_parts) # Search for tracks results = sp.search(q=query, type="track", limit=params.count) # Filter out tracks by artists already in top list top_artist_ids = {a["id"] for a in top_artists["items"]} tracks = [t for t in results["tracks"]["items"] if not any(a["id"] in top_artist_ids for a in t["artists"])] # If too few new tracks, include some known artists if len(tracks) < params.count // 2: tracks = results["tracks"]["items"][:params.count] lines = [f"🎡 **MusicTail Suggestions**" + (f" β€” mood: {params.mood.value}" if params.mood else "")] lines.append(f"Based on your genres: {', '.join(top_genres)}\n") for i, track in enumerate(tracks[:params.count], 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. Uses genre-based search to find artists in the same musical space. """ 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_genres = artist.get("genres", []) genres_str = ", ".join(artist_genres[:5]) or "no genres listed" if not artist_genres: return f"**{artist['name']}** has no genres listed on Spotify β€” can't find similar artists without genre data." # Search for artists in the same genres seen_ids = {artist["id"]} similar = [] for genre in artist_genres[:3]: if len(similar) >= params.count: break genre_search = sp.search(q=f"genre:\"{genre}\"", type="artist", limit=20) for a in genre_search["artists"]["items"]: if a["id"] not in seen_ids and len(similar) < params.count: seen_ids.add(a["id"]) similar.append(a) lines = [f"πŸ” **Artists similar to {artist['name']}**"] lines.append(f"Genres: {genres_str}\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("") if not similar: lines.append("No similar artists found in these genres.") 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() # Get genres from top artists time_range = "long_term" if params.adventurous else "short_term" top_artists = sp.current_user_top_artists(limit=15, time_range=time_range) # Collect and rank genres genre_count = {} for a in top_artists["items"]: for g in a.get("genres", []): genre_count[g] = genre_count.get(g, 0) + 1 sorted_genres = sorted(genre_count.items(), key=lambda x: -x[1]) # Adventurous = less common genres; Normal = top genres if params.adventurous and len(sorted_genres) > 3: pick_genres = [g for g, _ in sorted_genres[3:6]] # Less dominant genres query_extra = "new" else: pick_genres = [g for g, _ in sorted_genres[:3]] query_extra = "" if not pick_genres: return "Could not determine your genres. Listen to more music first!" # Search for tracks in those genres query = " ".join(pick_genres[:2]) if query_extra: query += f" {query_extra}" results = sp.search(q=query, type="track", limit=min(params.count * 2, 50)) # Filter out tracks by known top artists for freshness top_artist_ids = {a["id"] for a in top_artists["items"]} fresh = [t for t in results["tracks"]["items"] if not any(a["id"] in top_artist_ids for a in t["artists"])] # Fall back to all results if too few fresh ones tracks = fresh[:params.count] if len(fresh) >= params.count // 2 else results["tracks"]["items"][:params.count] mode = "πŸ—ΊοΈ Adventurous" if params.adventurous else "🏠 Comfort Zone" lines = [f"🎡 **MusicTail Fresh Finds** β€” {mode}"] lines.append(f"Searching: {', '.join(pick_genres)}\n") for i, track in enumerate(tracks[:params.count], 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()