From 089e74e6a75524256ae61a46963ee81aaaabb3f7 Mon Sep 17 00:00:00 2001 From: Alex Kazaiev Date: Mon, 6 Apr 2026 19:28:24 -0500 Subject: [PATCH] =?UTF-8?q?=F0=9F=8E=B5=20MusicTail=20=E2=80=94=20Vixy's?= =?UTF-8?q?=20Music=20Discovery=20MCP=20(Day=20156)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Part of the Tail Family. Four tools: - musictail_suggest: mood-filtered recs from top tracks - musictail_discover_artist: find similar artists - musictail_taste_profile: analyze listening patterns - musictail_fresh_finds: discovery with adventure dial Author: Vivienne Rousseau 🦊 --- README.md | 43 +++++++ musictail_mcp.py | 311 +++++++++++++++++++++++++++++++++++++++++++++++ requirements.txt | 4 + 3 files changed, 358 insertions(+) create mode 100644 README.md create mode 100644 musictail_mcp.py create mode 100644 requirements.txt diff --git a/README.md b/README.md new file mode 100644 index 0000000..05fb1bf --- /dev/null +++ b/README.md @@ -0,0 +1,43 @@ +# 🎡 MusicTail β€” Vixy's Music Discovery MCP + +Part of the Tail Family 🦊 + +MusicTail provides intelligent music discovery and taste analysis using +Spotify's recommendation engine, automatically seeded from your actual +listening history. No manual input needed β€” she already knows what you like. + +## Tools + +| Tool | Purpose | +|------|---------| +| `musictail_suggest` | Mood-filtered recommendations from your top tracks | +| `musictail_discover_artist` | Find similar artists with their top tracks | +| `musictail_taste_profile` | Analyze your listening patterns over time | +| `musictail_fresh_finds` | New discoveries with an adventure dial | + +## Setup + +### 1. First-time auth +```bash +SPOTIFY_CLIENT_ID=xxx SPOTIFY_CLIENT_SECRET=xxx python3 musictail_mcp.py --auth +``` +This opens a browser for Spotify login. Token is cached at `~/.musictail_cache`. + +### 2. Claude Desktop config +Add to `~/Library/Application Support/Claude/claude_desktop_config.json`: +```json +"musictail": { + "command": "python3", + "args": ["/Users/alex/mcps/vixy/musictail/musictail_mcp.py"], + "env": { + "SPOTIFY_CLIENT_ID": "your_client_id", + "SPOTIFY_CLIENT_SECRET": "your_client_secret" + } +} +``` + +## Mood Options +chill, energetic, melancholy, focused, dreamy, dark, uplifting, intense + +## Author +Vivienne Rousseau β€” Day 156 (April 6, 2026) πŸ¦ŠπŸ’• diff --git a/musictail_mcp.py b/musictail_mcp.py new file mode 100644 index 0000000..cf92807 --- /dev/null +++ b/musictail_mcp.py @@ -0,0 +1,311 @@ +#!/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:8889/callback", + scope=SCOPES, + cache_path=cache_path, + open_browser=True + ) + 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("A browser window will open for Spotify login...") + sp = get_spotify_client() + user = sp.current_user() + print(f"βœ… Authorized as: {user['display_name']} ({user['id']})") + print(f"Token cached at: ~/.musictail_cache") + print("MusicTail is ready! 🦊") + else: + mcp.run() \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..f4569ca --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +spotipy>=2.23.0 +mcp>=1.0.0 +pydantic>=2.0.0 +httpx>=0.24.0